import 'dart:async';

import 'package:stack_trace/stack_trace.dart';

typedef void ZeroArgFunction();
typedef void ErrorHandlingFn(error, stackTrace);

/// A `Timer` wrapper that lets you specify additional functions to call when it
/// is cancelled.
class WrappedTimer implements Timer {
  Timer _timer;
  ZeroArgFunction _onCancelCb;

  WrappedTimer(Timer timer) {
    _timer = timer;
  }

  void addOnCancelCb(ZeroArgFunction onCancelCb) {
    if (this._onCancelCb != null) {
      throw "On cancel cb already registered";
    }
    this._onCancelCb = onCancelCb;
  }

  void cancel() {
    if (this._onCancelCb != null) {
      this._onCancelCb();
    }
    _timer.cancel();
  }

  bool get isActive => _timer.isActive;
}

/// Stores error information; delivered via [NgZone.onError] stream.
class NgZoneError {
  /// Error object thrown.
  final error;

  /// Either long or short chain of stack traces.
  final List stackTrace;
  NgZoneError(this.error, this.stackTrace);
}

/// A `Zone` wrapper that lets you schedule tasks after its private microtask queue is exhausted but
/// before the next "VM turn", i.e. event loop iteration.
///
/// This lets you freely schedule microtasks that prepare data, and set an {@link onMicrotaskEmpty} handler that
/// will consume that data after it's ready but before the browser has a chance to re-render.
///
/// A VM turn consist of a single macrotask followed 0 to many microtasks.
///
/// The wrapper maintains an "inner" and "mount" `Zone`. The application code will executes
/// in the "inner" zone unless `runOutsideAngular` is explicitely called.
///
/// A typical application will create a singleton `NgZone`. The mount zone is the `Zone` where the singleton has been
/// instantiated. The default `onMicrotaskEmpty` runs the Angular change detection.
class NgZoneImpl {
  static bool isInAngularZone() {
    return Zone.current['isAngularZone'] == true;
  }

  // Number of microtasks pending from _innerZone (& descendants)
  int _pendingMicrotasks = 0;
  List<Timer> _pendingTimers = [];
  Function onEnter;
  Function onLeave;
  Function setMicrotask;
  Function setMacrotask;
  Function onError;

  Zone _outerZone;
  Zone _innerZone;

  /// Associates with this
  ///
  /// - a "mount" [Zone], which is a the one that instantiated this.
  /// - an "inner" [Zone], which is a child of the mount [Zone].
  ///
  /// @param {bool} trace whether to enable long stack trace. They should only be
  ///               enabled in development mode as they significantly impact perf.
  NgZoneImpl(
      {bool trace,
      this.onEnter,
      this.onLeave,
      this.setMicrotask,
      this.setMacrotask,
      this.onError}) {
    _outerZone = Zone.current;

    if (trace) {
      _innerZone = Chain.capture(() => _createInnerZone(Zone.current),
          onError: _onErrorWithLongStackTrace);
    } else {
      _innerZone = _createInnerZone(Zone.current,
          handleUncaughtError: _onErrorWithoutLongStackTrace);
    }
  }

  Zone _createInnerZone(Zone zone,
      {handleUncaughtError(
          Zone _, ZoneDelegate __, Zone ___, dynamic ____, StackTrace s)}) {
    return zone.fork(
        specification: new ZoneSpecification(
            scheduleMicrotask: _scheduleMicrotask,
            run: _run,
            runUnary: _runUnary,
            runBinary: _runBinary,
            handleUncaughtError: handleUncaughtError,
            createTimer: _createTimer),
        zoneValues: {'isAngularZone': true});
  }

  dynamic/*=R*/ runInnerGuarded/*<R>*/(/*=R*/ fn()) {
    return _innerZone.runGuarded(fn);
  }

  dynamic/*=R*/ runInner/*<R>*/(/*=R*/ fn()) {
    return _innerZone.run(fn);
  }

  /// Runs `fn` in the mount zone and returns whatever it returns.
  ///
  /// In a typical app where the inner zone is the Angular zone, this allows one to escape Angular's
  /// auto-digest mechanism.
  ///
  /// ```
  /// void myFunction(NgZone zone, Element element) {
  ///   element.onClick.listen(() {
  ///     // auto-digest will run after element click.
  ///   });
  ///   zone.runOutsideAngular(() {
  ///     element.onMouseMove.listen(() {
  ///       // auto-digest will NOT run after mouse move
  ///     });
  ///   });
  /// }
  /// ```
  dynamic/*=R*/ runOuter/*<R>*/(/*=R*/ fn()) {
    return _outerZone.run(fn);
  }

  dynamic _run(Zone self, ZoneDelegate parent, Zone zone, fn()) {
    try {
      onEnter();
      return parent.run(zone, fn);
    } finally {
      onLeave();
    }
  }

  dynamic _runUnary(Zone self, ZoneDelegate parent, Zone zone, fn(arg), arg) =>
      _run(self, parent, zone, () => fn(arg));

  dynamic _runBinary(Zone self, ZoneDelegate parent, Zone zone, fn(arg1, arg2),
          arg1, arg2) =>
      _run(self, parent, zone, () => fn(arg1, arg2));

  void _scheduleMicrotask(
      Zone self, ZoneDelegate parent, Zone zone, void fn()) {
    if (_pendingMicrotasks == 0) {
      setMicrotask(true);
    }
    _pendingMicrotasks++;
    var microtask = () {
      try {
        fn();
      } finally {
        _pendingMicrotasks--;
        if (_pendingMicrotasks == 0) {
          setMicrotask(false);
        }
      }
    };
    parent.scheduleMicrotask(zone, microtask);
  }

  // Called by Chain.capture() on errors when long stack traces are enabled
  void _onErrorWithLongStackTrace(error, Chain chain) {
    final traces = chain.terse.traces.map((t) => t.toString()).toList();
    onError(new NgZoneError(error, traces));
  }

  // Outer zone handleUnchaughtError when long stack traces are not used
  void _onErrorWithoutLongStackTrace(
      Zone self, ZoneDelegate parent, Zone zone, error, StackTrace trace) {
    onError(new NgZoneError(error, [trace.toString()]));
  }

  Timer _createTimer(
      Zone self, ZoneDelegate parent, Zone zone, Duration duration, fn()) {
    WrappedTimer wrappedTimer;
    var cb = () {
      try {
        fn();
      } finally {
        _pendingTimers.remove(wrappedTimer);
        setMacrotask(_pendingTimers.isNotEmpty);
      }
    };
    Timer timer = parent.createTimer(zone, duration, cb);
    wrappedTimer = new WrappedTimer(timer);
    wrappedTimer.addOnCancelCb(() {
      _pendingTimers.remove(wrappedTimer);
      setMacrotask(_pendingTimers.isNotEmpty);
    });

    _pendingTimers.add(wrappedTimer);
    setMacrotask(true);
    return wrappedTimer;
  }
}
